package com.cgrs572.shortlink.gateway.filter;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.cgrs572.shortlink.gateway.config.Config;
import com.cgrs572.shortlink.gateway.dto.GatewayErrorResult;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Mono;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * SpringCloud Gateway Token 拦截器
 * 通过继承AbstractGatewayFilterFactory<C>来自定义网关路由级别的过滤器---只对某一个路由生效
 * 注意：
 *  1.通过实现GlobalFilter是自定义网关的全局过滤器---对所有进行网关的请求都生效
 *  2.Spring Cloud Gateway 默认会自动将过滤器工厂的类名转换为短名称，如果类名以 GatewayFilterFactory 结尾，则自动截取前面的部分作为name
 *    比如：TokenValidateGatewayFilterFactory的name就会被默认设置为TokenValidate
 *    若想要自己设置name，则重写name()方法即可
 *  整个过程：
 *  即前端传来请求会先经网关路由过滤器，然后判断是否是指定的白名单uri，若是则直接放行进入到后管服务过滤器中；若不是则
 *  此时就需要通过用户的用户名和Token以及缓存来判断是否已登录，若已登录则向请求头中添加用户id和用户真是姓名并放行；若用户名
 *  或Token或缓存出错则代表登录信息错误，此时构建构建好的错误响应并返回给前端，前端收到响应后会自行处理
 */
@Component
public class TokenValidateGatewayFilterFactory extends AbstractGatewayFilterFactory<Config> {

    private final StringRedisTemplate stringRedisTemplate; // 用于操作Redis的工具类

    public TokenValidateGatewayFilterFactory(StringRedisTemplate stringRedisTemplate) {
        // 告知过滤器工厂该使用哪个配置类来接收yaml或properties文件中的参数
        super(Config.class); // 即指定路由过滤器工厂接收yaml或properties文件中相关参数的配置类
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            String requestPath = request.getPath().toString(); // 获取请求路径
            String requestMethod = request.getMethod().name(); // 获取请求方法
            // 判断是否是需要放行的请求，若是则跳过if语句直接放行
            if (!isPathInWhiteList(requestPath, requestMethod, config.getWhitePathList())) {
            // 若不是则执行if语句块中的代码
                String username = request.getHeaders().getFirst("username");
                String token = request.getHeaders().getFirst("token");
                // 获取用户对象(即UserDO)
                Object userInfo;
                // 判断用户名、token是否有值，并且Redis是否存在对应缓存---作用：进一步防止用户未登录或登录缓存失效 就访问相关信息
                if (StringUtils.hasText(username) && StringUtils.hasText(token) && (userInfo = stringRedisTemplate.opsForHash().get("short-link:login:" + username, token)) != null) {
                    // 若用户已登录则重设登录后的缓存失效时间---目的：避免用户在登录过程中发送请求时突然登录失效
                    stringRedisTemplate.expire("short-link:login:" + username, 30L, TimeUnit.MINUTES);
                    // 在用户登良逻辑代码中，用户登录成功后将用户的实体对象利用JSON.toJSONString方法转换为JSON字符串后存入的Redis缓存
                    // 所以此处需要先将从Redis缓存中获取到的数据转换为Java对象
                    JSONObject userInfoJsonObject = JSON.parseObject(userInfo.toString());
                    // exchange.getRequest()返回的是一个不可变的ServerHttpRequest对象，由于此处需要对请求中的相关信息进行设置，所以需要利用mutate()创建一个副本进行操作
                    // 利用headers设置请求头信息，使发送到服务的请求携带用户的相关信息（即用户id和用户真实姓名）
                    ServerHttpRequest.Builder builder = exchange.getRequest().mutate().headers(httpHeaders -> {
                        // 利用set方法设置请求头中指定名称的属性值，若指定名称存在则覆盖原值；反之则直接新增
                        httpHeaders.set("userId", userInfoJsonObject.getString("id"));
                        httpHeaders.set("realName", URLEncoder.encode(userInfoJsonObject.getString("realName"), StandardCharsets.UTF_8));
                    });
                    return chain.filter(exchange.mutate().request(builder.build()).build());
                }
                // 若用户名或token为空，或缓存中的用户信息已失效则执行以下代码
                ServerHttpResponse response = exchange.getResponse();
                response.setStatusCode(HttpStatus.UNAUTHORIZED); // 设置响应状态码为401

                /*
                * writeWith用于向http响应体中写入数据，并发送给前端---即将构建好的错误响应返回，终止过滤器链
                * 最终前端输入格式如下：
                *   {
                *       "status": 401,
                *       "message": "Token validation error"
                *   }
                * 前端收到响应后会自行处理
                * 注意：原来的思路是如果存在用户名或Token或缓存为空的情况，就说明登陆错误，就会跳转到登录页面重新登录，
                *      但是现在改为了直接返回一个Response响应体给前端，让前端去自行处理，也就是说让前端去进行页面跳转一类的操作
                 */
                return response.writeWith(Mono.fromSupplier(() -> {
                    DataBufferFactory bufferFactory = response.bufferFactory(); // 获取当前相应的缓冲区工厂
                    GatewayErrorResult resultMessage = GatewayErrorResult.builder()
                            .status(HttpStatus.UNAUTHORIZED.value()) // 设置响应状态码为401---代表客户端未提供有效的身份认证
                            .message("Token validation error") // 设置响应信息
                            .build();
                    // JSON.toJSONString将 GatewayErrorResult 序列化为 JSON 字符串，并利用getBytes()将JSON 字符串进一步转换为字节数组
                    return bufferFactory.wrap(JSON.toJSONString(resultMessage).getBytes());
                }));
            }
            // 若前端请求是需要放行的请求则直接跳过上述if代码块，执行以下代码---即直接放行
            return chain.filter(exchange);
        };
    }

    /**
     * 判断请求是否是可以直接放行的uri
     * @param requestPath 请求路径
     * @param requestMethod 请求方法
     * @param whitePathList 白名单列表
     * @return
     */
    private boolean isPathInWhiteList(String requestPath, String requestMethod, List<String> whitePathList) {
        // 判断请求是否是白名单中直接放行的uri(即用户登录、检查用户名是否可用)---若不是则执行放行前策略，反之则直接放行
        boolean figure1 = !CollectionUtils.isEmpty(whitePathList) && whitePathList.stream().anyMatch(requestPath::startsWith);
        // 判断请求是否是用户注册---若不是则执行放行前策略，反之则直接放行
        // 注意：用户注册与用户修改的uri一致，但是请求方式不一致，所以用户注册要单拿出来进行单独处理
        boolean figure2 = Objects.equals(requestPath, "/api/short-link/admin/v1/user") && Objects.equals(requestMethod, "POST");
        return figure1 || figure2; // 若返回True则代表前端请求为需要放行的请求---只要有一个为True就返回True
    }
}
